Skip to content

[FEATURE] Ontology-guided extraction and typing - #539

Open
halcoope wants to merge 3 commits into
mainfrom
ontology-guided-extraction
Open

halcoope wants to merge 3 commits into
mainfrom
ontology-guided-extraction

Conversation

@halcoope

Copy link
Copy Markdown
Collaborator

Title: [FEATURE] Ontology-guided extraction and typing

Description

Two related capabilities, both opt-in and both driven from an OWL/RDFS ontology you supply.

Extraction guided by a vocabulary. The ontology's entity types, permitted relationships
and attributes are rendered into the extraction prompts, and a deterministic filter then
resolves what the model emitted against the ontology — so a graph names the same idea one
way rather than several. Company rather than Company and CORPORATION; worksFor
rather than WORKS_FOR and EMPLOYED BY.

Typing of extracted values. Values of declared datatype properties are coerced to their
declared XSD type and written onto entity nodes as native graph properties, so
WHERE c.foundedYear < 2000 becomes a query you can run against the graph rather than
something only an LLM can answer from text.

With no ontology configured, the emitted graph queries and the extraction prompts are
byte-identical to a build from before this change.

Changes

Vocabulary

  • New package indexing/extract/ontology/ (8 files, ~3,100 lines): ontology loading and
    structural validation, a plain-data read index that crosses the extraction process
    boundary, prompt rendering, XSD coercion, and the filter.
  • The ontology is rendered into both extraction prompts, so the model is told the
    vocabulary instead of inventing one. Composed into the templates rather than substituted
    into a placeholder, so a no-ontology prompt is unchanged and existing LLM caches stay
    valid.
  • OntologyFilter, a deterministic TransformComponent running after the topic
    extractor: it resolves each emitted name, rewrites resolved names to the authored
    spelling, drops what does not conform, and annotates survivors so the build stage needs no
    ontology knowledge of its own.
  • One setting spans the axis. ontology_authority='off' says nothing; 'align' (the
    default) stores a matched concept under the ontology's name and keeps what the ontology
    does not declare; 'strict' also excludes what does not conform. The level resolves to six
    independent dimensions, each individually overridable, so a single gate can be asked for by
    name.
  • vocabulary_format: 'prose' (default) renders three labelled sections, one per
    output channel; 'turtle' shows the ontology's own source.

Typing

  • Coercion of declared literals to their XSD type: integer families with their declared
    bounds, decimals, booleans, dates, times and URIs. Lenient about lexical form — digit
    grouping, a zero fraction, named-month dates — and strict about meaning: a literal that
    would need interpreting rather thahan stored under a key whose
    name promises a number. Every value that coerces is JSON-serializable, because it travels
    through node metadata into Cypher parameters.
  • **typed_properties chooses where: 'off' (default) writes
    nothing, 'subject' keys it from the property's own local name on the subject entity,
    'complement' writes typed_value / datatype on the value node, 'both' does each to
    its own node.
  • enforce_datatypes makes typing authoritative at 'strict': a literal that does not
    parse as its declared type takes the fact with it, rather than being stored untyped.
  • **The write is additive by construis a separate query, so the
    entity insert is byte-identical at every placement and value, search_str and class
    cannot be disturbed. An ontology declaring a property name the graph model already owns is
    refused at configuration time rath

Also

  • Small edits at the call sites in lexical_graph_index.py, build_pipeline.py,
    entity_graph_builder.py, local_entity_rewrites_graph_builder.py, config.py,
    constants.py, prompts.py and t
  • Unrelated, included here rather than split: pytest's default norecursedirs contains
    build, so tests/unit/indexing/build/ had never been collected in a full-suite or CI
    run. Removing it surfaced six buil passed, plus five tests
    elsewhere already failing on main.es of test changes with
    nothing to do with ontologies.

Problem

Extraction is by default only lightly guided: the LLM is seeded with preferred entity
classifications and asked to prefer are unguided altogether.
That keeps recall high, but the same concept can arrive under several names, which costs
retrieval precision and makes the graph hard to query directly.

Extracted attribute values have the a founding year or a
revenue figure is stored as the textng downstream can filter,
sort or compare on it. There was no way to say "these are the types and relationships I
want", and no way to have a declared datatype land as a native graph property.

Related issue (if any): none

Testing

  • Unit tests added/updated — 641 tests across the ontology and typed-property files.
    Everything is driven from hand-built inputs and a small fixture ontology; no recorded
    model output is checked in.
  • Integration tests added (as appropriate) — none. An env-gated live test was written
    during development and deliberately removed: it required a specific Neptune Analytics
    graph, so it would have been p
  • Existing tests pass (pytest)pre-existing live tests
    gated on NEO4J_TEST_URI / Ss own invocation including --cov-fail-under=56`; total coverage 73.74%. The new package is at 100% of statements
    on six of seven modules and 99.4% on the seventh.
  • Tested manually — end-to-end bAnalytics graph during
    development, with typed properties confirmed present via
    CALL neptune.graph.pg_schema(). Documentation site builds clean.

Checklist

  • Code follows existing style an
  • License headers present on new files — verified against the same check CI runs
  • Documentation updated — new page at
    docs-site/src/content/docs/lextraction.mdx, plus the
    ontology parameter in the Indexing page
  • No breaking changes — the feature is opt-in and defaults to 'off' at every layer;
    typed_properties is delibera, so no ambient
    configuration can turn on graph writes. Enabling an ontology is a re-index boundary
    (normalize_names can change entity.class, which feeds the entity id), and th documented.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@github-actions

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at unknown% (target: unknown%). Download the HTML report here.

@github-actions

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at unknown% (target: unknown%). Download the HTML report here.

Lets a user supply an OWL/RDFS ontology as the extraction vocabulary, so that a
graph names the same idea one way rather than several. Opt-in: with no ontology
configured, the emitted graph queries and the extraction prompts are byte-identical
to a build from before this change.

Two mechanisms. The ontology's classes and properties, with their comments, are
rendered into the extraction prompts, so the model is told the vocabulary instead
of inventing one. After extraction, a deterministic transform component resolves
each emitted name against the ontology and - depending on the authority the
ontology is given - rewrites it to the authored spelling, or drops what does not
conform.

One setting spans that axis. `ontology_authority='off'` says nothing; `'align'`,
the default, stores a matched concept under the ontology's name and keeps what the
ontology does not declare; `'strict'` also excludes what does not conform. The
level resolves to six independent dimensions, each individually overridable, so a
single gate can be asked for by name.

Optionally, values of declared datatype properties are coerced to their declared
XSD type and written onto entity nodes as native graph properties, which is what
makes a numeric or date comparison possible in a query. Coercion is lenient about
lexical form - digit grouping, named-month dates - and strict about meaning: a
literal that would need interpreting rather than parsing is refused rather than
stored under a key whose name promises a number.

The new code is one package, indexing/extract/ontology/: loading and validation, a
plain-data read index that crosses the extraction process boundary, prompt
rendering, XSD coercion, and the filter. Everything else is small edits at the call
sites.

Also included, and unrelated: pytest's default norecursedirs contains "build", so
tests/unit/indexing/build/ had never been collected in a full-suite or CI run.
Removing it surfaced six builder test files that no longer passed, plus five tests
elsewhere that were already failing on main.

Documented at docs-site/.../lexical-graph/ontology-guided-extraction.mdx.
@halcoope
halcoope force-pushed the ontology-guided-extraction branch from 3091051 to 4e625b3 Compare September 11, 2026 21:04
@github-actions

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at 73.7% (target: 80%). Download the HTML report here.

@aghassel aghassel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-generated review. Reviewed manually before posting.

Nice piece of work. Because the feature is opt-in and the no-ontology path really is untouched, none of the below affects
existing users. Everything is scoped to people who turn it on. Two things I'd like to see fixed before
merge, and a family of four that I think matters more than its individual severity suggests:

Blocking

  1. typed_properties='complement'/'both' silently produces nothing — the carry clause added to
    copy_complement_relationships_to_subject never executes, and the complement's typed_value is
    DETACH DELETEd with the node. 13 passing tests assert the query's text, so they certify the dead
    path. Detail inline.

  2. entityId is missing from RESERVED_ENTITY_PROPERTIES, so a subject typed write can overwrite the
    __Entity__ merge key on property-keyed stores. Detail inline.

The thing I'd most like to change about the feature as a whole: it has no loud failure mode.
report_violations defaults to False, and four separate paths make a misconfigured ontology
indistinguishable from a working one — an RDFS-dialect file loads to zero terms with no warning;
vocabulary_format='turtle' names classes the response parser cannot resolve back; enforce_*='off'
turns the gate on; and combining an ontology with infer_entity_classifications drops the ontology's
class names out of the prompt. Each is a few lines. Comments inline.

The rest of what I found is small and listed in one follow-up comment rather than scattered inline.


if typed_properties in COMPLEMENT_PLACEMENTS:
(typed_value_key, datatype_key) = COMPLEMENT_ENTITY_PROPERTIES
carry_typed_values = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This carry never runs, so at typed_properties='complement'/'both' the coerced value is lost.

copy_complement_relationships_to_subject receives no rows in either write mode. Driving
GraphBatchClient over a recording store with the root query returning one row, the executed queries
were, in order:

get complements matching subject
delete complement relationships      <- contains DETACH DELETE c
get subjects matching complement
delete complement relationships

The copy-with-carry query never executed; with batch_writes_enabled=False, nothing in the tree executed
at all. So the complement node — carrying the typed_value/datatype this PR just wrote onto it — is
deleted and nothing is carried onto the surviving subject.

test_local_entity_rewrites_typed_carry.py (13 tests, all passing) asserts on the generated query text,
so it certifies the clause exists rather than that it runs.

Two ways out: fix the execution defect (materialise the root results into a list before fanning out to
children in QueryTree.run, and iterate the generator on the non-batch path), or drop the clause and say
in the placement table that complement typed values don't survive the local-entity fold. Either way I'd
switch those tests to assert on the (query, params) pairs that actually reach the store through
GraphBatchClient — that's the assertion that would have caught this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right on every mechanical point here, and I verified each one separately:

  • the carry clause never executes — confirmed;
  • copy_complement_relationships_to_subject receives no rows in either write mode —
    confirmed;
  • with batch_writes_enabled=False, nothing in the tree executes at all — confirmed;
  • the 13 tests assert generated query text and therefore certify a dead path — confirmed, and
    a fair hit
    .

One thing in your favour that I don't think the comment claims: this isn't a regression in this
PR.
The copy query, its wiring as a child_queries entry, and both QueryTree constructions
are byte-identical to main; the PR adds only the carry clause. query_tree.py,
graph_batch_client.py and graph_store.py — where the defect actually lives — aren't touched
here at all. I ran the same probe in a clean main worktree with no ontology code present and no
typed_properties kwarg: copy executes 0 times batched, nothing executes unbatched. So
local-entity folds have never moved complement relationships onto the surviving subject, for
anyone, ontology or not.

Where I'd push back is the conclusion — "at typed_properties='complement'/'both' the coerced
value is lost"
as a general statement. Your repro drove the tree "with the root query returning
one row", which forces the fold to match. How often it matches on real input turns out to be the
whole question.

Root query 2 (get subjects matching complement) cannot match at all under the default
include_classification_in_entity_id=True. Its lookup key is
complement.altEntityId = create_entity_id(value, '__Local_Entity__'), but no node is ever
written with an id built from __Local_Entity__ as a classification — real entities hash their
real class, and complement nodes use create_local_entity_id, which is a different node-type
prefix and source-scoped:

create_entity_id('help@acme.test', '__Local_Entity__') = 57844660354b037e…
create_entity_id('help@acme.test', 'Person')           = 53da0b24893139cb…
equal: False

So root query 1 is the only live path, and it needs a __Local_Entity__ node whose
search_str equals a real (non-local) subject entity's search_str — a genuine collision
between a value and an entity name.

Driving the real EntityGraphBuilder and LocalEntityRewritesGraphBuilder through a
GraphBatchClient, against a store that keeps node state and answers both root queries
faithfully rather than returning a fixed row:

A. Ordinary attribute values (1994, 1971, 41500000)
   typed_value surviving: 3        deleted by the fold: 0

B. A value that is also a real entity ('help@acme.test' is a Person elsewhere)
   typed_value surviving: 0        deleted by the fold: 1

So what leaving this alone actually costs:

Placement Cost
'subject' None. The subject write is a separate plain-string query that batches normally and never touches the fold. This is the documented recommended placement.
'complement' The typed value is lost only on a complement whose string is also extracted as a named entity — and that node is deleted from the graph today regardless, so nothing that survives is wrong.
'both' Subject half unaffected; complement half as above.

And it lands where the typed value is worth least: in case B the declared range is xsd:string,
so typed_value is a byte-for-byte copy of value. The ranges where coercion adds something —
integer, decimal, date, boolean — are the ones whose values are essentially never also entity
names.

On that basis I've left this untouched and I'd like to treat it as a documented edge case
rather than a blocker
, with the underlying defect raised separately (issue to follow). I took your option
(a) — fix QueryTree.run and the unbatched path — seriously, and it's contained (that builder is
the only QueryTree consumer in src/), but it changes graphs produced by builds with no
ontology at all
:

Config Default? Impact of fixing QueryTree
include_local_entities=False, batched yes None. Query stream byte-identical — no __Local_Entity__ node exists, so both root queries match nothing
include_local_entities=False, unbatched no Extra round trips that write nothing (UNWIND []). Graph unchanged
include_local_entities=True, batched no Graph gains (s)-[:__RELATION__{value}]->(n) and (n)-[:__OBJECT__]->(f). Additive
include_local_entities=True, unbatched no The whole fold starts running; local-entity nodes get merged and DETACH DELETEd where they currently survive

The default is safe, but that last row is more than a feature PR should carry. Happy to be argued
out of this if you think the silent edge loss is urgent enough to want it fixed in here.

Your testing point stands regardless, and I'm not trying to wriggle out of it: those 13 tests
assert query text, which is exactly what let a clause on a dead query read as a working
feature. I'd rather move them to asserting the (query, params) pairs that reach the store as
part of that follow-up, since the same assertions are what would prove the fix works — rather than
rewriting them here to assert the current dead behaviour, which would just bake the bug into the
suite.

Comment thread lexical-graph/src/graphrag_toolkit/lexical_graph/indexing/constants.py Outdated
@aghassel

Copy link
Copy Markdown
Contributor

AI-generated review. Reviewed manually before posting.

Smaller things, roughly in the order I'd care about them. Happy to open issues instead if you prefer.

Correctness

  • entity_graph_builder.py:364,417new_query_var() is interpolated into the query text, and
    GraphBatchClient keys batches on the query string, so every typed value becomes its own single-row
    round trip. Measured with 50 attribute facts at batch_write_size=25: off → 2 round trips,
    subject → 52, both + include_local_entities → 104. A fixed variable name is safe here (the
    variable is local to a one-statement query).
  • datatype_utils.py:227_coerce_datetime/_coerce_time strip the trailing offset and then parse, so
    it's discarded rather than applied, and coercion reports success. 2020-03-03T23:00:00-05:00 and
    2020-03-04T04:00:00Z are the same instant and store unequal; -05:00 and +09:00 are 14h apart and
    store identical. That defeats the range queries the feature exists for, and on Neptune a _date-suffixed
    key wraps the zone-stripped string in datetime().
  • datatype_utils.py:231_coerce_datetime is the only coercer with no shape regex, so its accepted
    grammar is datetime.fromisoformat's, which widened in 3.11. '2020-03-03T09:30:00.5' coerces on 3.12
    and returns None on 3.10 (supported per requires-python), which at strict is the difference between
    keeping and dropping the fact. An _ISO_DATETIME regex would make the boundary yours.
  • datatype_utils.py:242_coerce_time picks its strptime format from the colon count ('%H:%M:%S',
    no %f), so the (\.\d+)? group in _ISO_TIME is dead and every fractional-second xsd:time is
    rejected — while xsd:dateTime accepts and truncates the same fraction.
  • datatype_utils.py:210_ISO_DATE admits a leading - and _coerce_date then does
    lstrip('-'), so -0500-01-01 (500 BCE) stores as 0500-01-01 and reports as conforming.
    _coerce_datetime refuses the same input, so the asymmetry looks unintended.
  • ontology.py:537rdfs:subClassOf owl:Thing is a fatal OntologyLoadError, though _class_reference
    (:371) already exempts OWL_THING for domain/range. It's a legal, Protégé-emitted axiom, and the
    workaround (declaring owl:Thing a owl:Class) injects a meaningless Thing entity type into the
    prompt and the rendered class tree.
  • ontology_index.py:137 — nothing checks that declared terms have distinct resolution_keys.
    schema:Person + foaf:Person loads clean; class_by_key['person'] is one of them and the other is
    unreachable by every name it has, while the renderers still name it. At strict those facts are dropped
    and reported as enforce_domain_range, pointing at a declaration that is correct. The
    label-vs-local-name shape is sharper: :Company rdfs:label "Organisation" alongside :Organisation
    makes the compliant emission resolve to the other class, and since create_entity_id hashes the
    classification, the two collapse onto one node id with a conflicting classIri — which also breaks the
    idempotence invariant documented at ontology_filter.py:20-25.
  • naming.py:97 — render uses .upper(), fold uses .lower(), and they aren't inverses for names whose
    case mapping isn't fold-stable. rdfs:label "Größe" renders GRÖSSE, which resolves to nothing
    (index holds größe and groesse). Worse, Straße and Strasse both render STRASSE, so a compliant
    emission resolves to the wrong property and takes that property's datatype. casefold() plus indexing
    the rendered form would close it; the invariant test currently only covers local_name, ASCII.
  • ontology_filter.py:446strict prunes statement.facts but leaves statement.value, and
    StatementNodeBuilder writes the __Statement__ node unconditionally, so the rejected assertion is
    still in the graph and in the statement vector index — the primary retrieval unit. Measured: statement
    survives with facts: 0. Worth either scoping the claim in the authority table or dropping/logging
    statements left with no facts. (topic.entities is never gated either.)
  • neptune_graph_stores.py:174 — the widened except (TypeError, ValueError) still lets dateutil's
    OverflowError through: '99999999999999999999' under a _date-suffixed key raises out of
    create_property_assigment_fn_for_neptune, graph_construction.py:161-166 re-raises, and nothing in
    build_pipeline.py catches it, so the run dies mid-batch. Unlike the pre-existing metadata caller, this
    value comes from LLM output. Also the one production line this PR changes in the file, currently untested.
  • neptune_graph_stores.py:170datetime() wrapping is decided by property-name suffix, ignoring the
    rdfs:range the call site already holds, and binds the raw literal rather than the normalised value.
  • ontology.py:453datatype=ranges[0] takes the alphabetically-first of a sorted set with no
    multiplicity check, while _class_reference logs for exactly this ambiguity on the domain of the same
    object one line above.
  • ontology.py:526 — range validation only checks startswith(XSD_NAMESPACE), so xsd:Integer /
    xsd:datetime / a bare namespace all load clean and are indistinguishable downstream from a real
    built-in whose text fallback is deliberate.
  • ontology.py:355_values() stringifies literals and drops Literal.language, and _first_value()
    returns the alphabetically-first, so which language wins is decided by spelling, independently per
    predicate. A @de label beat an @en one in my test.
  • ontology.py:275 — with vocabulary_format='turtle', a blank node referenced by more than one subject
    (a reused owl:Restriction) gets a fresh _:nXXXX label per process: three runs, three different
    blocks, three different sha256s. Since LLMCache keys on the formatted prompt
    (llm_cache.py:163-165), that's a 100% cache miss per chunk per run and no byte-reproducible build.
  • prompts.py:303 — on the empty-constraints path with_ontology_constraints returns the template
    unchanged instead of clearing {ontology_constraints}, so a custom template using that documented
    extension point sends the literal brace group to the model.
  • datatype_utils.py:259 — nine string-derived XSD types (NCName, ID, language, …) dispatch to
    _coerce_text, which returns any input unchanged, yet validates_datatype reports True for them
    because it's derived from _COERCERS.keys() — so the "unvalidated" warning is suppressed for types
    whose value space is never checked.
  • ontology_filter.py:723 — when the model doesn't repeat the object string in its entity block, the
    parser yields a complement, resolution.object_class stays None, and _class_satisfies returns True
    for None, so the whole object side escapes both enforce_domain_range and enforce_entity_types at
    strict.
  • ontology_filter.py:540 — no exclusion for the pipeline's own sentinel: resolution_key('__Local_Entity__')
    is 'local entity', so a declared :LocalEntity (or a "Local Entity" label) rewrites the sentinel at
    align and every downstream == LOCAL_ENTITY_CLASSIFICATION guard then misses.
  • pipeline_utils.py:72,77 — the violation report only reaches spawn workers via _applied_logging_config,
    written exclusively by the new apply_logging_config, so a process that used stdlib basicConfig gets
    no report at all; and the dictConfig is passed as an initarg with no picklability check, unlike
    config_snapshot beside it, which pickle-tests each field and warns.

Security / robustness (author-supplied ontology, so low, but cheap)

  • prompt_constraint.py:485rdfs:comment is interpolated verbatim, so a newline plus ## forges a
    section heading in the rendered block (reproduced: two ## Using this vocabulary headings), ``` escapes
    the turtle fence, and nothing rejects the | the response protocol uses as its field separator.
  • datatype_utils.py:133_ANY_URI's ^\S+\.\S+\S*$ ends in \S+\S*; the trailing \S* is dead and
    only turns a linear rejection into a quadratic one (2/8/16 KB → 0.017/0.264/1.046 s).

Docs / hygiene

  • ontology-guided-extraction.mdx:400, :81-83, :207-209 — the three "an off setting is a no-op"
    claims. ontology_authority='off' still rewrites {preferred_entity_classifications} from the ontology,
    and the placement table's 'off' row promises a byte-identical graph.
  • graph-model.mdx:101-103 — the reserved-name guard is narrower than stated: typed_value is accepted at
    'subject' placement, and value is refused only at subject placements.
  • The "Writing an ontology" section documents none of the load-time rules that will actually reject a file
    (every referenced class must be locally declared, owl:imports isn't followed, subClassOf cycles,
    dual-typed properties, missing/non-XSD ranges).
  • ontology_filter.py:54 and two sibling comments assert an rdflib-free import boundary for extraction
    workers; a submodule import runs every ancestor __init__, and both ancestors import rdflib
    (confirmed: importing ontology_index alone leaves rdflib in sys.modules). The plain-data claim is
    true and worth keeping — it's just the import claim that isn't.
  • lexical-graph/output.log — tracked, zero bytes, added by this commit; the only tracked *.log in the
    repo and not covered by .gitignore (which already ignores output.log under two other paths).

Method note: findings were reproduced against a real venv (rdflib 7.6.0, Python 3.12 plus a 3.10 for
the version-divergence item), not read off the diff.

Correctness

- Reserve `entityId`: a typed subject write could key on the `__Entity__` merge
  key, making the node unreachable by id on property-keyed stores (Neo4j,
  FalkorDB). Assert against the emitted Cypher rather than the constant.
- Refuse dimension overrides that are not True/False/None. `bool('off')` is
  True, so `enforce_entity_types='off'` turned the gate on - and `'off'` is a
  valid value for three other settings on the same constructor.
- Refuse a zoned `xsd:dateTime`/`xsd:time` instead of stripping the offset,
  which stored the same instant as different strings and 14h apart as identical
  ones, both reported as conforming. `xsd:date` still drops it.
- Accept fractional seconds for `xsd:time`; the `(\.\d+)?` group was dead.
- Refuse a leading `-` on `xsd:date` rather than stripping the sign, which
  stored 500 BCE as 500 CE.
- Union the ontology's class names into `InferClassifications` output, exempt
  from truncation. They were dropped, so the prompt named classes that
  `{preferred_entity_classifications}` did not, and `strict` then discarded what
  inference produced.
- Resolve a class by a separator-free fallback key when the exact key misses,
  so the ontology's own spelling works: the response parser title-cases
  `SportsTeam` to `Sportsteam`. Ambiguous coarse keys are omitted, never
  guessed, and the exact path is unchanged. Classes only - predicates already
  round-trip.
- Accept `rdfs:subClassOf owl:Thing`, a legal axiom Protege emits; it was a
  fatal dangling reference.
- Fold with `casefold()` rather than `lower()`, so a rendered name is the
  inverse of its indexed key for non-ASCII (`Grosse`/`GROSSE`).
- Catch `OverflowError` from dateutil on Neptune; an out-of-range numeric
  killed the build mid-batch.
- Clear `{ontology_constraints}` when the block is empty, instead of sending the
  literal placeholder to the model.
- Fix batching: `new_query_var()` in the query text gave every typed write its
  own round trip. 50 facts at batch_write_size=25: subject 52 -> 4, both
  104 -> 8.

Diagnostics, where the feature had no loud failure mode

- Warn when a non-empty ontology declares no OWL terms, naming `rdfs:Class` and
  `rdf:Property`. An RDFS-dialect file loaded to zero terms silently, then
  dropped every fact at `strict`.
- Warn on colliding resolution keys, on multiple `rdfs:range` values, and on
  multiple `rdfs:label`/`rdfs:comment` values, naming the winner in each case.
- Report `validates_datatype()` False for the nine string-derived XSD types
  whose lexical space is never checked; only `xsd:string` is unconstrained.
  Stored values are unchanged.
- Pickle-test the logging config before handing it to spawn workers, as the
  config snapshot already does per field.

Reproducibility and robustness

- Renumber blank-node labels in the turtle vocabulary block, which rdflib mints
  per process. The prompt is now byte-reproducible, so `LLMCache` stops missing.
- Make `xsd:anyURI` rejection linear. The pattern was cubic on the input it
  exists to reject: 4 KB of dotted prose took 28s.
- Flatten `rdfs:comment` to one line, so a newline plus `##` cannot forge a
  section heading in the rendered block.

Docs

- Correct the three claims that an `off` setting is a no-op; the ontology still
  seeds the preferred classifications.
- Correct the reserved-name guard, including which placements it runs at.
- Document the load-time rules, `owl:imports` not being followed, and the two
  cases that load clean and do nothing.
- State that every dimension gates facts only: statements with no surviving
  facts, and `topic.entities`, are never pruned.
- Correct the rdflib-free import claims; the plain-data claim is the one holding.
- Untrack `lexical-graph/output.log` and ignore `output.log`.
@halcoope

Copy link
Copy Markdown
Collaborator Author

Worked through all of these. Every one reproduced. Fourteen are fixed, nine are closed with a
reason, and one is worse than you measured.

Where you were too generous: _ANY_URI

You had 2/8/16 KB → 0.017/0.264/1.046 s. On my machine it grows 8× per doubling, i.e. cubic:

dotted prose + one space:  500 → 0.054s   1000 → 0.431s   2000 → 3.44s   4000 → 28.5s

And dropping the dead trailing \S*, which is what you proposed, removes the constant but
leaves ^\S+\.\S+$ quadratic — every candidate dot is still retried against every tail:

after dropping \S*:        2000 → 0.005s   8000 → 0.084s  16000 → 0.330s

So I replaced the alternation rather than patching it. The original language is exactly "contains
no whitespace, AND (has a scheme prefix OR has a . with a character on each side)", which is
three linear tests. Differentially verified against the original regex over 300,022 inputs — the
hand-written edge shapes plus 300k random strings over [A-Za-z0-9./:#?+- \t]0 mismatches.
64 KB now takes 0.0002 s. Worth flagging that the pathological input class is precisely the one
the check exists to reject, and the value comes from LLM output.

Fixed

Item Note
datatype_utils.py:227 timezone discarded Zoned dateTime/time now refused, not stripped. Converting to UTC was considered and rejected — it rewrites what the text stated, which the prompt promises the model it will not do, and leaves a naive local time indistinguishable from a converted one. xsd:date still drops the offset, deliberately: the value there is a calendar date
datatype_utils.py:242 fractional xsd:time (\.\d+)? is live now; xsd:time and xsd:dateTime agree on the same lexical form
datatype_utils.py:210 leading - on _ISO_DATE Refused outright, matching what _coerce_datetime already did. The lstrip('-') is gone
datatype_utils.py:133 _ANY_URI Above
datatype_utils.py:259 string family Nine types removed from _COERCERS; they fall through to the existing unimplemented path, which already returns trimmed text and already warns. Stored values byte-identical. Detail below
entity_graph_builder.py:364,417 round trips Fixed variable names. subject 52 → 4, both 104 → 8. insert_domain_entity keeps its uuid — pre-existing, and its own comment already flags batching as a separate optimisation
ontology.py:537 subClassOf owl:Thing owl:Thing filtered out of parents, consistent with _class_reference already exempting it for domain and range. Your point about the workaround injecting a meaningless Thing type was the deciding one
ontology.py:453 datatype=ranges[0] Warns on multiplicity, matching the sibling _class_reference warning one line above
ontology.py:355 language tags Warns and names the winner. No language preference applied — picking @en would put an opinion about language in exactly one place with no setting to change it, and the author is better placed to resolve it in the ontology. The warning notes that for rdfs:label the winner becomes the canonical stored spelling
ontology.py:275 blank-node instability Labels renumbered to _:b1, _:b2 in first-appearance order before the turtle reaches the prompt. Three processes now produce an identical block; previously three different sha256s. Skolemising would not have helped — it mints a fresh UUID too
ontology_index.py:137 duplicate resolution keys Warns, naming the winner. Extra evidence for you: class_names() returns the duplicate, so a collision also seeds {preferred_entity_classifications} with a repeated name
naming.py:97 .upper() / .lower() resolution_key folds with casefold(). Fixes Größe; Straße/Strasse now collide as one key rather than one being silently unresolvable, which the new collision warning reports
neptune_graph_stores.py:174 OverflowError Added to the caught tuple, with tests for out-of-range numbers and non-strings
prompts.py:303 literal {ontology_constraints} Cleared on the empty path. One existing test asserted the old behaviour; its rationale was avoiding a blank line, which is not worth sending a literal brace group to the model
prompt_constraint.py:485 rdfs:comment Flattened to one line, so a newline plus ## can no longer forge a heading. Reproduced your two ## Using this vocabulary headings first. The test asserts on line starts, not substrings — flattening demotes the forged heading to prose rather than deleting the words
All four docs/hygiene items Below

Closed, with reasons

Item Why
ontology.py:526 range validated by prefix only Already audible: a misspelt range produces enforce_datatypes cannot validate …#Integer, declared as the range of p. The code path is shared with deliberate text fallbacks, as you say, but the diagnostic is not
neptune_graph_stores.py:170 suffix vs rdfs:range Threading the range through property_assigment_fn is a public-interface change touching every store implementation and caller. And the existing fallback provably absorbs it: int/float/bool raise TypeError, non-date text raises ParserError, so the wrapper only applies where it is meaningful. Documented instead — the docs now say the decision is made on the name alone
ontology_filter.py:723 object side escapes both gates "Unknown is not violating" is documented design, and there is genuinely no object class to test: the parser never matched the object text to an entity, so the fact has a literal complement. Not fixable without changing what the parser emits. Documented in the authority table
ontology_filter.py:446 statements survive with facts: 0 Reproduced — statement kept, text still in the vector index. Scoped the claim rather than pruning: the authority table now states that every dimension gates facts and only facts, that such a statement still reaches the graph and the statement vector index, and that topic.entities is never pruned either
pipeline_utils.py:72 report unreachable under basicConfig Not a regression — origin/main passed no logging config to workers at all, so this PR strictly improved things and the gap only affects users who were no better off before. And the fix is worse than the gap: basicConfig(level=INFO) inside a worker turns on INFO for every component to deliver one line per batch. Documented as requiring set_logging_config
pipeline_utils.py:77 dictConfig not pickle-tested Fixed, not closed — it now pickle-tests and degrades to None with a warning, matching what config_snapshot does per field

On the string family

Your framing was right and the fix was smaller than expected. Of the ten, only xsd:string is
genuinely unconstrained — returning the literal unchanged is the check for it. The other nine
have restricted lexical spaces that nothing here checks. Removing them from _COERCERS is the
whole change: they fall through to the unimplemented-type path, which already returns trimmed text
and already warns once per type, so stored values are identical and the user is simply told.

I chose not to implement the lexical checks. At strict a failing value is dropped, and losing a
fact because a token carried two consecutive spaces is a worse outcome than storing it
unchecked. These types document intent; they are not worth making into a gate.

Also fixed the validates_datatype docstring, which claimed True "for every XSD type with an
implementation, including the string family"
— the clause that made this look intentional.

Docs and hygiene

  • The three "off is a no-op" claims: corrected. 'off' still seeds
    {preferred_entity_classifications}, and the placement table's byte-identical claim now scopes
    to the emitted queries.
  • graph-model.mdx reserved names: rewritten, including which placements the check runs at.
  • Writing an ontology: new "What is checked when the file loads" subsection covering every
    load-time rule, owl:imports not being followed, and the two cases that load clean and do
    nothing.
  • The rdflib-free import claim: corrected in three comments. You are right that the plain-data
    claim is the one that holds — confirmed that importing ontology_index alone leaves rdflib
    in sys.modules, because indexing.extract.__init__ re-exports the package.
  • lexical-graph/output.log: untracked, and output.log added to .gitignore.

Two grammar decisions I left to Python

_coerce_datetime still has no shape regex, deliberately. I tested 3.10 rather than assume: the
divergence is confined to fractional-second digit counts other than 3 or 6, ISO basic format, and
week dates. Every form a model realistically emits behaves identically on both. Pinning that would
mean owning a date parser, so it is documented instead, including that a strict build on 3.10
can retain marginally fewer facts.

Thanks for the method note at the end of your comment — reproducing against a real venv rather
than reading the diff is why almost all of this was actionable without a round of argument.

Flattening newlines closed one of the three vectors in the review; these are the
other two.

- Size the turtle fence to the content. A comment carrying a run of backticks is
  serialized verbatim, so a fixed three-backtick fence was closed early by it,
  putting the rest of the ontology and the protocol section after it outside the
  code block. One more backtick than the longest run, per CommonMark.
- Replace `|` in a description. It is the response protocol's field separator -
  the prompt asks for `entity|RELATIONSHIP|entity` and `parse_extracted_topics`
  splits on it - so a `|` from a comment reads to the model as a worked example
  of the output it is being asked to produce. A slash rather than a deletion, so
  "revenue|turnover" still reads as the alternation the author meant.

Neither is escaped: this is prose fed to a model, not a format with an escape
syntax.
@halcoope

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment, on prompt_constraint.py:485.

I listed that item under "Fixed" without qualifying it, and only one of the three vectors you
named was actually closed. Flattening newlines stopped a comment forging a ## heading; the other
two still reproduced:

backtick-fence count in block: 3   (a single fenced block should have 2)
rendered line: Company  "A company|FOUNDED_YEAR|1994"

So a comment carrying ``` did close the fence early — putting the rest of the ontology and the
protocol section after it outside the code block — and | went through untouched.

Both now fixed in ea7b4e83:

  • The turtle fence is sized to the content: one more backtick than the longest run in it, per
    CommonMark. A comment containing ``` gets a four-backtick fence, and the protocol section stays
    outside it.
  • | in a description is replaced with /. It is the response protocol's field separator — the
    prompt asks for entity|RELATIONSHIP|entity and parse_extracted_topics splits emitted lines
    on it — so a | arriving from an rdfs:comment reads to the model as a worked example of the
    output it is being asked to produce, in the middle of the vocabulary it is being asked to use.
    A slash rather than a deletion, so a comment like "revenue|turnover" still reads as the
    alternation the author meant.

Neither is escaped, because there is nothing to escape to — this is prose fed to a model, not a
format with an escape syntax. Four tests cover it, including that an ordinary comment still gets a
plain three-backtick fence.

While I am correcting the record, one other qualifier I should have made explicit: on
ontology_index.py:137, "warns, naming the winner" is the whole of the change. The first-wins
resolution policy is unchanged, so the losing term is still unreachable by every name it has — the
warning tells the author to fix it rather than the code resolving it differently. If you think the
policy itself should change, that is worth its own discussion; I did not want it read as resolved.

@acarbonetto acarbonetto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a nice contribution.

block = _neutralize_format_fields(constraints)

if ONTOLOGY_CONSTRAINTS_PLACEHOLDER in template:
return template.replace(ONTOLOGY_CONSTRAINTS_PLACEHOLDER, block)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The {ontology_constraints} placeholder path uses template.replace(...) (all occurrences) while the anchor path one group below uses count=1. Was this intended?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants